这交互炸了系列 第十三式之移花接木
本文作者
作者:陈小缘
链接:
https://blog.csdn.net/u011387817/article/details/89142467
本文由作者授权发布。
小缘对于自定义 ViewGroup 的理解真的非常深刻,每次的文章总能给我带来惊喜,我经常看到炫酷的动效都会发给他一下...PS这小伙很会做饭。
上篇:
文章非常长,超过了微信 1/3的限制,全文会有删减,建议大家收藏一波,估计路上是看不完了,但是非常值得学习!
上个星期更新了网易云音乐之后,在发现->歌单页面中看到一个挺炫酷的效果,介系我没有见过的船新版本,看图:
对,一眼看上去就像是在ViewPager的基础上改造过,但仔细看,又不太像ViewPager的行为,因为它固定只有三个子View(我特意观察了几天),而且,在滑动的时候,除了尺寸和透明度的渐变,跟ViewPager有一个明显的区别就是,最前面的子View会向相反方向移动。
这就像六一儿童节孩子们排队领糖果一样:最前面的领到了糖果,还想再领一次,于是就到最后面重新排队。哈哈
还有一个比较细节的效果就是,在手指滑动到屏幕宽度的一半左右,本来在中间的子View跟即将来到中间的子View他们会交换层级顺序,看:
emmmm,这样的话,基本不用考虑改造ViewPager了,直接自定义ViewGroup吧,而且ViewPager使用起来还要定义Adapter,很繁琐。
先来观察一下它的行为:
1. 静止的时候,中间大,两边小并且半透明,最左边的子View看上去是在总宽度的1/4上,也就是这三个子View把屏幕宽度分成了4份;
2.滑动时,在最前面的子View会向相反方向移动,但它的透明度和尺寸都不变;中间的子View,在移动过程中会越来越透明,尺寸也会越来越小;后面的子View刚好跟中间的相反,它会变得越来越大而且不透明度也越来越大;
3. 在手指移动了大概屏幕宽度的一半时,后面的两个子View会交换层级顺序;
4. 手指松开后,会根据当前滑动的距离自行调整位置,即像ViewPager那样;
5. 静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间,即一个选中的效果;
再根据它的行为来捋一下大致思路:
1. 既然说把屏幕宽度分成了4份,那就是三条线,我们刚好可以根据这三条线来作为基准线,用来辅助子View定位;
2. 可以用一个变量来记录当前手指滑动距离相对于屏幕宽度的百分比,有了这个百分比,要计算出来这些alpha,scale之类的就轻而易举了;
3. 这个我们在下面讨论;
4. 这个没难度,ACTION_UP之后播放一个ValueAnimator来更新位置就行了;
5. 后面讨论;
那这个交换子View层级顺序的效果,这个应该怎么做呢?
很多同学第一时间可能会想到:先remove,再add回去。
嗯,这样做虽然也能实现交换顺序,但是还是重量级了点,在一些低端机上面还可能会出现闪一下的效果(因为移除了之后不能及时地add回去)。我们还有更高效和更轻量的方法:
了解过RecyclerView回收机制的同学,应该对LayoutManager中的detachView和attachView方法很有印象:
在进行滚动的时候,LayoutManager会不断地detach无效的item,重新绑定数据之后,又会立即attach回去,那么,我们做交换层级顺序的,也可以用这种方式,来试试。
在ViewGroup中,这两个方法分别对应detachViewFromParent和attachViewToParent,但都是用protected修饰的,我们要在外面调用它就要创建一个类继承现有的ViewGroup然后重写这两个方法并把protected改为public:
public void detachViewFromParent(View child) {
super.detachViewFromParent(child);
}
public void attachViewToParent(View child, int index, ViewGroup.LayoutParams params) {
super.attachViewToParent(child, index, params);
}
接着我们在Activity中监听子View点击事件:哪个子View被点击,就置顶哪个:
public void moveToTop(View target) {
//先确定现在在哪个位置
int startIndex = mViewGroup.indexOfChild(target);
//计算一共需要几次交换,就可到达最上面
int count = mViewGroup.getChildCount() - 1 - startIndex;
for (int i = 0; i < count; i++) {
//更新索引
int fromIndex = mViewGroup.indexOfChild(target);
//目标是它的上层
int toIndex = fromIndex + 1;
//获取需要交换位置的两个子View
View from = target;
View to = mViewGroup.getChildAt(toIndex);
//先把它们拿出来
mViewGroup.detachViewFromParent(toIndex);
mViewGroup.detachViewFromParent(fromIndex);
//再放回去,但是放回去的位置(索引)互换了
mViewGroup.attachViewToParent(to, fromIndex, to.getLayoutParams());
mViewGroup.attachViewToParent(from, toIndex, from.getLayoutParams());
}
//刷新
mViewGroup.invalidate();
}
好,来看看效果:
哈哈,可以了。
上面我们观察到,当那个ViewGroup静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间。
网易云的做这个就舒服些,因为它可以在子View接收到onClick事件的时候,先跟这个ViewGroup互动一下,但因为我们做的是一个库,不可能叫大家自己去监听onClick后,还通知一下ViewGroup的,所以我们要在内部处理好这个逻辑,也就是要拦截子View的点击事件了。
到这里有同学可能会问:拦截子View点击事件?直接重写onInterceptTouchEvent方法,在里面判断并return true不就行了?
没错,大致流程是这样,但现在的问题是:
如何找到这个被点击的View,来进行选择性的拦截?
这时候同学就会说:判断是否在子View[left, top, right, bottom]内不就行了。
emmmm,这个方法在一般情况下是可以,但是,如果子View应用过scale,translation,rotation之类的变换,就有问题了,再加上我们等下处理滑动的时候,还要将子View应用scale变换呢。
同学又会说:这种效果,在绝大多数情况下,子View都不会单独应用那些变换的,那我们也不用scale,要缩放就直接layout成子View的目标尺寸不就行了?
这样的话,直接判断是否在边界内就行啦
不,直接layout成缩放后的大小是不行的,因为如果有子View在xml里面就已经写死了长宽,那么它在测量完之后,getMeasuredWidth和getMeasuredHeight方法通常会返回这个写死的值(这里为什么是用通常,而不用总是呢?
因为这个数值完全取决于那个子View,有的自定义View可能根本不会理会这个设置的值),这样一来,我们就控制不了它实际的尺寸了(除非是用wrap_content或match_parent),这种情况,在进行布局时,如果不按照它实际大小去layout,那么就会出现好像子View被裁剪了一样(如果layout的尺寸比实际的尺寸小的话)
所以我认为网易云的效果,它子View是定制过的,也就是说那几个子View会根据最终layout出来的尺寸去调整View的内容。但是如果作为一个库的话,不可能让大家都像网易云这样做的,所以我们就选择用scale的方式来做了。
好了,来想想应该怎么拦截吧:
本来的思路是通过反射拿到onClickListener,然后在这个listener上面再套一层我们自己的listener的,结果看源码的时候发现:
View.ListenerInfo(全部监听器都是由这个静态内部类来保管)里面的mOnClickListener是标记了@hide的!!!,又因为9.0系统禁止调用Hidden API的缘故(这里暂不讨论要怎么调用Hidden API),所以只能绕路走了,我们想一下其他方法。
还记不记得我们上次分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件:
我们这次也刚好可以用的上,因为子View在移动过程中会进行scale操作,像刚刚那位同学说的直接判断是否在子View[left, top, right, bottom]内是不行的,正确的做法应该要像ViewGroup那样:
先检查一下这个子View所对应的矩阵有没有应用过变换,如果有的话,还要先把触摸坐标映射到矩阵变换之前的对应位置,再来判断是否在View内。
那我们现在就来模拟一下,如何判断手指当前在哪个子View上:
先看ViewGroup的源码:
public void transformPointToViewLocal(float[] point, View child) {
...
if (!child.hasIdentityMatrix()) {
child.getInverseMatrix().mapPoints(point);
}
}
它先是调用子View的hasIdentityMatrix方法来判断是否应用过变换,如果有的话,会接着调用getInverseMatrix方法。。。。
咦??等等,为什么我们平时写代码的时候,AS没有出现过这几个方法的提示呢?点进去看看先:
final boolean hasIdentityMatrix() {
return mRenderNode.hasIdentityMatrix();
}
看。。看看getInverseMatrix和pointInView方法:
/**
* @hide
*/
public final Matrix getInverseMatrix() {
ensureTransformationInfo();
if (mTransformationInfo.mInverseMatrix == null) {
mTransformationInfo.mInverseMatrix = new Matrix();
}
final Matrix matrix = mTransformationInfo.mInverseMatrix;
mRenderNode.getInverseMatrix(matrix);
return matrix;
}
/**
* @hide
*/
public boolean pointInView(float localX, float localY, float slop) {
return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
localY < ((mBottom - mTop) + slop);
}
@hide @hide @hide!
没事!来仔细看一下它们各自方法内的实现,
hasIdentityMatrix(),里面是直接调用mRenderNode的hasIdentityMatrix;
getInverseMatrix(),核心就是mRenderNode.getInverseMatrix(matrix)这句,也就是说,他也是依赖于mRenderNode的;
pointInView(),就是几个简单的判断,里面[mLeft, mRight, mTop, mBottom]我们也完全可以在外面使用它们的get方法来获取,这就代表着我们可以自己在外面定义方法,来代替它这个pointInView;
那么,我们现在来看mRenderNode了,先检查下它的声明:
/**
* RenderNode holding View properties, potentially holding a DisplayList of View content.
* <p>
* When non-null and valid, this is expected to contain an up-to-date copy
* of the View content. Its DisplayList content is cleared on temporary detach and reset on
* cleanup.
*/
final RenderNode mRenderNode;
太好了,没有被标记@hide,接下来看看它里面的hasIdentityMatrix和getInverseMatrix方法:
public boolean hasIdentityMatrix() {
return nHasIdentityMatrix(mNativeRenderNode);
}
public void getInverseMatrix(@NonNull Matrix outMatrix) {
nGetInverseTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
}
哇,这么好!居然是public的,也就是说,我们连反射都省了。
那再看看它这个类本身是不是也是public的:
/**
* ...
* ...
* @hide
*/
public class RenderNode {
啊,绝望,RenderNode这个类被标记了@hide。。。
不得不说,Google爸爸在这方面确实做得很绝考虑得很周到。
怎么办,要妥协吗?
其实,还有一个不是很装逼优雅的方法也可以正确判断到当前手指在哪个View上:
我们刚刚的思路,不是有记录每个子View的scale的吗?
我们可以用子View的当前宽高来乘对应的scale,最终得出缩放后的[left, top, right, bottom],并保存在自定义的LayoutParams中;
定义pointInView方法,在里面判断[x, y]是否在刚刚保存的边界范围内就行;
emmmm,在没有其他更好的办法的情况下,用这种方法也是挺好的,起码能解决问题。
既然有后手,为何不尝试一下?
细心的同学会发现,View里面也有个getMatrix方法,这个方法可以在外部调用,即没有被标记@hide的:
public Matrix getMatrix() {
ensureTransformationInfo();
final Matrix matrix = mTransformationInfo.mMatrix;
mRenderNode.getMatrix(matrix);
return matrix;
}
可以看到,它最终也是通过RenderNode的getMatrix方法来实现的,来看看RenderNode的实现:
public void getMatrix(@NonNull Matrix outMatrix) {
nGetTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
}
有没有发现,这个getMatrix,跟我们刚刚在上面贴出来的hasIdentityMatrix和getInverseMatrix方法一样,都是直接调用对应的native方法的
而熟悉Matrix的同学会知道,在Matrix里面也有个isIdentity方法,那么,
Matrix的isIdentity跟RenderNode的hasIdentityMatrix之间又有着什么样的联系呢?
RenderNode的getMatrix和getInverseMatrix之间又有什么不同呢?它们里面究竟做了些什么呢?
emmmm,源码会给我们答案。
在系统源码 /frameworks/base/core/jni/ 目录下,会看到一个叫android_view_RenderNode.cpp的文件
// 篇幅过长原因L这里省略了 native 分析部分,直接给出结论了。
现在我们完全可以不使用反射,来做到像ViewGroup那样,判断触摸点是否在子View范围内了。
现在来试一下:
/**
* @param view 目标view
* @param points 坐标点(x, y)
* @return 坐标点是否在view范围内
*/
private boolean pointInView(View view, float[] points) {
// 像ViewGroup那样,先对齐一下Left和Top
points[0] -= view.getLeft();
points[1] -= view.getTop();
// 获取View所对应的矩阵
Matrix matrix = view.getMatrix();
// 如果矩阵有应用过变换
if (!matrix.isIdentity()) {
// 反转矩阵
matrix.invert(matrix);
// 映射坐标点
matrix.mapPoints(points);
}
//判断坐标点是否在view范围内
return points[0] >= 0 && points[1] >= 0 && points[0] < view.getWidth() && points[1] < view.getHeight();
}
哈哈哈,成功了!完美避开使用反射。
那么等下写代码的时候,就可以将这个方法应用到我们的ViewGroup当中,用来拦截子View原有的点击事件。
好啦,那么现在我们总体的思路也有了,是时候开始写代码了。
在开始之前,先给我们的ViewGroup起个名字吧,因为它的行为比较像ViewPager,不过它的Item又不能像ViewPager那样不固定,在可扩展性这方面是不及ViewPager。
但是,ViewPager使用起来的流程比较繁琐,还要定义Adapter之类的,而我们这个就不用,所以在易用性方面,我们的ViewGroup更胜一筹。
在日常开发中,我们所使用的数据库通常都是SQLite,寓意是轻量级的数据库,那么我们的ViewGroup也可以叫LitePager,寓意是轻量级的ViewPager,挺洋气的名字,哈哈哈哈哈,就叫LitePager吧。
测量
好,开始吧,首先是onMeasure,那宽高应该怎么确定呢? 如果宽高设置了wrap_content的话:
宽度可以用它那三个子View的宽度之和;
高度就使用它的子View中高度最大的吧(大多数场景下子View高度都是统一的);
来看看代码怎么写:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 先测量子View
measureChildren(widthMeasureSpec, heightMeasureSpec)
val width = measureWidth(widthMeasureSpec)
val height = measureHeight(heightMeasureSpec)
setMeasuredDimension(width, height)
}
private fun measureHeight(heightMeasureSpec: Int): Int {
var height = 0
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize
} else {
//如果高度设置了wrap_content,则取最大的子View高
var maxChildHeight = 0
for (i in 0 until childCount) {
val child = this[i]
val layoutParams = child.layoutParams as LayoutParams
maxChildHeight = Math.max(maxChildHeight, child.measuredHeight
+ layoutParams.topMargin + layoutParams.bottomMargin)
}
height = maxChildHeight
}
return height
}
private fun measureWidth(widthMeasureSpec: Int): Int {
var width = 0
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize
} else {
//如果宽度设置了wrap_content,则取全部子View的宽度和
for (i in 0 until childCount) {
val child = this[i]
val layoutParams = child.layoutParams as LayoutParams
width += child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
}
}
return width
}
虽然是Kotlin代码,但是逻辑挺清晰的,就算不熟悉Kotlin的同学也能很轻易的看懂(还没开始学Kotlin的同学赶快跟上大家的脚步啦~)。
布局
好,接下来到布局了,上面我们分析过,可以用三条基准线来辅助定位,来看看代码怎么写:
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (index in 0 until childCount) {
val child = this[index]
//获取基准线
val baseLine = getBaselineByChild(child)
//布局子View
layoutChild(child, baseLine)
}
}
private fun getBaselineByChild(child: View) =
//根据子View在ViewGroup中的索引计算基准线
when (indexOfChild(child)) {
0 -> width / 4 //左边的线 (最底的子View放在左边)
1 -> width / 2 + width / 4 //右边的线 (接着放在右边)
2 -> width / 2 //中间的线 (最顶的子View放在中间)
else -> 0
}
private fun layoutChild(child: View, baseLine: Int) {
//获取子View测量宽高
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
//垂直的中心位置,即高度的一半
val baseLineCenterY = height / 2
//根据基准线来定位水平上的位置
val left = baseLine - childWidth / 2
val right = left + childWidth
//垂直居中
val top = baseLineCenterY - childHeight / 2
val bottom = top + childHeight
val lp = child.layoutParams as LayoutParams
child.layout(left + lp.leftMargin + paddingLeft,
top + lp.topMargin + paddingTop,
right + lp.leftMargin - paddingRight,
bottom + lp.topMargin - paddingBottom)
}
可以看到,获取基准线被定义成了一个单独的方法getBaselineByChild,为什么呢,因为等下处理滑动手势的时候,这个基准线是需要动态计算的。
有同学可能会问:这个方法里面的width是从哪里来的呢?没看到有在哪里声明啊
这个也是Kotlin的特性之一,我们看到的width,其实是访问getter方法,在这里也就是getWidth()了,还有layoutChild方法里面的height也是同理,调用的是getHeight()。
好,来看看初步的效果(为了更容易理解,加上了辅助线):
emmmm,挺好。
处理缩放和透明度
上面我们看到网易云的效果是两边的子View会变小和变透明,那么这些属性(缩放比例、透明度)肯定要用变量保存起来的,保存在哪里好呢?
用一个集合来装吗?当然不是了,我们可以扩展LayoutParams,把这些属性都放在LayoutParams里面:
因为我们的ViewGroup需要支持Margin,所以继承自MarginLayoutParams:
class LayoutParams : MarginLayoutParams {
var scale = 0F
var alpha = 0F
constructor(c: Context, attrs: AttributeSet) : super(c, attrs)
constructor(width: Int, height: Int) : super(width, height)
constructor(source: ViewGroup.LayoutParams) : super(source)
}
定义好之后,还要重写三个生成LayoutParams的方法,并在里面返回我们自己的LayoutParams:
override fun generateLayoutParams(attrs: AttributeSet) = LayoutParams(context, attrs)
override fun generateLayoutParams(p: ViewGroup.LayoutParams) = LayoutParams(p)
override fun generateDefaultLayoutParams() = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
那么,LayoutParams中的scale和alpha在哪里初始化好呢?
当然是在addView方法里了,在这里,我们还可以顺便限制一下子View的个数(因为超过三个的话,就不知道应该怎么布局了。。。网易云上也只有固定的三个):
override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
if (childCount > 2) {
//满座了就直接拋异常,提示不能超过三个子View
throw IllegalStateException("LitePager can only contain 3 child!")
}
//如果传进来的LayoutParams不是我们自定义的LayoutParams的话,就要创建一个
val lp = if (params is LayoutParams) params else LayoutParams(params)
if (childCount < 2) {
lp.alpha = mMinAlpha
lp.scale = mMinScale
} else {
lp.alpha = 1F
lp.scale = 1F
}
super.addView(child, index, params)
}
可以看到,我们重写的addView方法,在限制子View个数之后,会进行判断:如果是最后一个,也就是第三个添加进来的子View,alpha和scale才是"正常"的(缩放比例和不透明度都是1,即100%)
mMinAlpha和mMinScale用来保存最小的不透明度和缩放比例,当然了,这两个值是可以给外部修改的,默认值分别是0.4和0.8。
接下来,还需要修改一下刚刚的layoutChild方法:在进行layout之前,先更新一下不透明度和缩放比例:
private fun layoutChild(child: View, baseLine: Int) {
val lp = child.layoutParams as LayoutParams
//更新不透明度
child.alpha = lp.alpha
//更新缩放比例
child.scaleX = lp.scale
child.scaleY = lp.scale
//其他地方不变
...
...
}
这个child.xxx = xxx,其实是调用setter方法啦,child.alpha = lp.alpha 等于java中的 child.setAlpha(lp.alpha);
好了,来看看效果:
emmmm,可以看到,现在的效果已经跟网易云的差不多了,下面我们来添加手势滑动的效果。
支持滑动手势
网易云的处理方式是:子View从当前基准点移动到下一个基准点时,偏移量刚好等于ViewGroup的宽度,也就是说,当手指的水平滑动距离=ViewGroup宽度时,这个ViewGroup也刚好切换页面了(即进行了一次完整的滑动)。
这样的话,我们就需要计算手指水平滑动的百分比,然后转换成基准线之间的百分比,计算出偏移的距离后,还需要进行以下处理:
1. 如果手指是向左滑动,那么最左边的子View(index=0),要向右偏移。反之,如果是向右滑动的话,最右边的子View(index=1)要向左偏移;
2. 当滑动到ViewGroup宽度的一半时,新旧的中间View要交换层级顺序;
3. 当水平滑动的距离超出ViewGroup宽度时,应该当作是新的一次偏移了,这个在每次计算前判断一下就行了;
第一个没什么难度,先用indexOfChild方法获取到子View在ViewGroup中的索引然后根据这个索引来判断就行了。
第二个,我们在开头的时候就已经分析过要怎么交换顺序了,所以等下可以直接把那个方法应用到这里来;
第三,如果当前向右滑动的距离=ViewGroup的宽度,也就是说这时候已经进行了一次完整的滑动了:本来在右边的子View,现在已经到了左边、本来在中间的,现在在右边、本来在左边的,现在到了中间。那么,如果现在继续向右滑动的话,一开始在右边的子View(现在在左边),就要向右移动而不是上一次的向左了,其他两个子View同理。
这种情况的话,怎么去正确的判断哪个子View是要往左,哪个要往右呢?想一下
有没有发现,这种问题可以用我们生活中的场景来辅助解决:我们平时叫滴滴,你告诉师傅你在哪,要去哪里,只要你的出发地和目的地正确,师傅就能顺利的把你送到目的地。哈哈哈
那么,我们也可以在LayoutParams里面记录子View的from和to,在每一次完整的滑动之后,更新每一个子View的这两个值。
嗯,就这么决定了,开始写代码:
首先是重新onInterceptTouchEvent方法,我们要在这里去判断并拦截触摸事件:
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN -> {
//更新上一次的触摸坐标
mLastX = x
mLastY = y
}
MotionEvent.ACTION_MOVE -> {
val offsetX = x - mLastX
val offsetY = y - mLastY
//判断是否触发拖动事件
if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) {
//更新上一次的触摸坐标
mLastX = x
mLastY = y
//标记已开始拖拽
isBeingDragged = true
}
}
MotionEvent.ACTION_UP -> {
//标记没有在拖拽
isBeingDragged = false
}
}
return isBeingDragged
}
这里跟一般的ViewGroup没什么不同,大致逻辑就是:判断手指的移动距离是否>指定值,如果是,就拦截并标记正在拖拽。
接下来到onTouchEvent,我们在里面要处理的逻辑有:
更新滑动百分比;
更新子View的出发点和目的地;
更新子View的层级顺序;
更新子View的不透明度和缩放比例;
来看看代码:
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val offsetX = x - mLastX
mOffsetX += offsetX
onItemMove()
}
MotionEvent.ACTION_UP -> {
isBeingDragged = false
}
}
mLastX = x
mLastY = y
return true
}
可以看到我们把处理ACTION_MOVE单独定义了一个方法,把刚刚说的要做的事情都放在onItemMove里面:
private fun onItemMove() {
//更新滑动百分比
mOffsetPercent = mOffsetX / width
//更新子View的出发点和目的地
updateChildrenFromAndTo()
//更新子View的层级顺序
updateChildrenOrder()
//更新子View的不透明度和缩放比例
updateChildrenAlphaAndScale()
//请求重新布局
requestLayout()
}
先来看一下如何更新出发点和目的地(updateChildrenFromAndTo()),逻辑稍微有点复杂,要仔细看一下注释。
private fun updateChildrenFromAndTo() {
//如果滑动的距离>=ViewGroup宽度
if (Math.abs(mOffsetPercent) >= 1) {
//遍历子View,标记已经到达目的地
for (i in 0 until childCount) {
val lp = this[i].layoutParams as LayoutParams
lp.from = lp.to
}
//处理溢出: 比如总宽度是100,现在是120,那么处理之后会变成20
mOffsetX %= width.toFloat()
//同理,这个是百分比
mOffsetPercent %= 1F
} else {
//遍历子View,并根据当前滑动的百分比来更新子View的目的地
for (i in 0 until childCount) {
val lp = this[i].layoutParams as LayoutParams
lp.to = when (lp.from) {
//最左边的子View,如果是向右滑动的话,那么它的目的地是中间,也就是2了
//如果是向左滑动的话,目的地是最右边的位置,也是1了,下面同理
0 -> if (mOffsetPercent > 0) 2 else 1
//最右边的子View,如果是向右滑动,那么目的地就是最左边(0),反之,在中间(2)
1 -> if (mOffsetPercent > 0) 0 else 2
//中间的子View,如果向右滑动,目的地是右边(1),向左就是左边(0)
2 -> if (mOffsetPercent > 0) 1 else 0
else -> return
}
}
}
}
可以看到有一句是lp.to = when (lp.from),还没开始学习Kotlin的同学可能会不了解
这个when,有点像java的switch,但是跟switch最大的区别就是,when是一个表达式,也就是可以有返回值,所以上面那句lp.to = when (lp.from),这个lp.to,最终接收的时候when里面的if else的返回值。
好,接下来看看updateChildrenOrder方法,要注意的是,每次滑动距离超过50%的时候只会交换一次顺序,除了这个还要处理回退的问题,也就是滑动超过一半后(这时已经交换过顺序),又反方向滑动,这时候也要交换一次顺序:
private fun updateChildrenOrder() {
//如果滑动距离超过了ViewGroup宽度的一半,
//就把索引为1,2的子View交换顺序,并标记已经交换过
if (Math.abs(mOffsetPercent) > .5F) {
if (!isReordered) {
exchangeOrder(1, 2)
isReordered = true
}
} else {
//滑动距离没有超过宽度一半,即有可能是滑动超过一半然后滑动回来
//如果isReordered=true,就表示本次滑动已经交换过顺序
//所以要再次交换一下
if (isReordered) {
exchangeOrder(1, 2)
isReordered = false
}
}
}
现在还不行,还要在刚刚的updateChildrenFromAndTo方法内判断滑动完成那里,重置这个isReordered,因为如果不在那里重置的话,在下一次该交换顺序的时候就会出问题了,我们来修改一下updateChildrenFromAndTo方法:
private fun updateChildrenFromAndTo() {
if (Math.abs(mOffsetPercent) >= 1) {
//在这里要重置一下标记
isReordered = false
//其他地方不变
...
} else {
//其他地方不变
...
}
}
好,回到updateChildrenOrder方法中,可以看到交换顺序的exchangeOrder方法,也就是我们一开始分析的那段:
private fun exchangeOrder(fromIndex: Int, toIndex: Int) {
//一样的就不用换了
if (fromIndex == toIndex) {
return
}
//先获取引用
val from = this[fromIndex]
val to = this[toIndex]
//分离出来
detachViewFromParent(from)
detachViewFromParent(to)
//重新放回去,但是index互换了
attachViewToParent(from, if (toIndex > childCount) childCount else toIndex, from.layoutParams)
attachViewToParent(to, if (fromIndex > childCount) childCount else fromIndex, to.layoutParams)
//通知重绘,刷新视图
invalidate()
}
还有最后的updateChildrenAlphaAndScale,这个逻辑也有点复杂,要仔细看注释:
private fun updateChildrenAlphaAndScale() {
//遍历子View
for (i in 0 until childCount) {
updateAlphaAndScale(this[i])
}
}
private fun updateAlphaAndScale(child: View) {
val lp = child.layoutParams as LayoutParams
//用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
when (lp.from) {
//最左边的子View
0 -> when (lp.to) {
//如果它目的地是最右边的话
1 -> {
//要把它放在最底,为了避免在移动过程中遮挡其他子View
setAsBottom(child)
//透明度和缩放比例都不用变,因为现在就已经满足条件了
}
//如果它要移动到中间
2 -> {
//根据滑动比例来计算出当前的透明度和缩放比例
lp.alpha = mMinAlpha + (1F - mMinAlpha) * mOffsetPercent
lp.scale = mMinScale + (1F - mMinScale) * mOffsetPercent
}
}
//最右边的子View
1 -> when (lp.to) {
0 -> {
//把它放在最底,避免在移动过程中遮挡其他子View
setAsBottom(child)
//透明度和缩放比例都不用变
}
2 -> {
//这里跟上面唯一不同的地方就是mOffsetPercent要取负的
//因为它向中间移动的时候,mOffsetPercent是负数,这样做就刚好抵消
lp.alpha = mMinAlpha + (1F - mMinAlpha) * -mOffsetPercent
lp.scale = mMinScale + (1F - mMinScale) * -mOffsetPercent
}
}
//中间的子View
2 -> {
//这里不用判断to了,因为无论向哪一边滑动,不透明度和缩放比例都是减少
lp.alpha = 1F - (1F - mMinAlpha) * Math.abs(mOffsetPercent)
lp.scale = 1F - (1F - mMinScale) * Math.abs(mOffsetPercent)
}
}
}
setAsBottom方法也就是把目标子View的索引跟索引0交换顺序:
private fun setAsBottom(child: View) {
//获取child索引后跟0交换层级顺序
exchangeOrder(indexOfChild(child), 0)
}
还记不记得我们刚刚重写onLayout方法的时候,有个获取基准线的方法(getBaselineByChild),里面返回的数值是写死的?
我们现在还要改一下它,改成根据滑动百分比(mOffsetPercent)来动态计算基准线:
private fun getBaselineByChild(child: View): Int {
//左边View的初始基准线
val baseLineLeft = width / 4
//中间的
val baseLineCenter = width / 2
//右边的
val baseLineRight = width - baseLineLeft
var baseLine = 0
val lp = child.layoutParams as LayoutParams
//用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
when (lp.from) {
//左边的子View
0 -> baseLine = when (lp.to) {
//目的地是1,证明手指正在向左滑动,所以下面的mOffsetPercent是用负的
//当前基准线 = 初始基准线 + 与目标基准线(现在是右边)的距离 * 滑动百分比
1 -> baseLineLeft + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
//如果目的地是中间(2),那目标基准线就是ViewGroup宽度的一半了(baseLineCenter),计算方法同上
2 -> baseLineLeft + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
else -> baseLineLeft
}
//右边的子View
1 -> baseLine = when (lp.to) {
//原理同上
0 -> baseLineRight + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
2 -> baseLineRight + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
else -> baseLineRight
}
//中间的子View
2 -> baseLine = when (lp.to) {
//原理同上
0 -> baseLineCenter + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
1 -> baseLineCenter + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
else -> baseLineCenter
}
}
return baseLine
}
好啦,来看看现在的效果是怎么样的:
哈哈哈,差不多了,接下来我们处理一下手指松开的事件:当手指松开后,要播放选中动画。
加入选中动画
终于来到简单的部分了,我们先改一下onTouchEvent和onInterceptTouchEvent方法,在ACTION_UP里面加上一个handleActionUp方法:
override fun onTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_UP -> {
...
handleActionUp(x, y)
}
}
...
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_UP -> {
...
handleActionUp(x, y)
}
}
...
}
private fun handleActionUp(x: Float, y: Float) {
playFixingAnimation()
}
可以看到handleActionUp里面先是直接调用了playFixingAnimation方法:
private fun playFixingAnimation() {
//没有子View还播放什么动画,出去
if (childCount == 0) {
return
}
//起始点,就是当前的滑动距离
val start = mOffsetX
//结束点
val end = when {
//如果滑动的距离超过了宽度的一半,那么就顺势往那边走下去
//如果滑动百分比是正数,表示是向右滑了>50%,所以目的地就是宽度
mOffsetPercent > .5F -> width.toFloat()
//相反,如果是负数,那就拿负的宽度
mOffsetPercent < -.5F -> -width.toFloat()
//如果滑动没超过50%,那就把距离变成0,也就是回退了
else -> 0F
}
startValueAnimator(start, end)
}
private fun startValueAnimator(start: Float, end: Float) {
if (start == end) {
//起始点和结束点一样,那还播放什么动画,出去
return
}
//先打断之前的动画,如果正在播放的话
abortAnimation()
//创建动画对象
mAnimator = with(ValueAnimator.ofFloat(start, end)){
//指定动画时长
duration = mFlingDuration
//监听动画更新
addUpdateListener { animation ->
val currentValue = animation.animatedValue as Float
//更新滑动距离
mOffsetX = currentValue
//处理子View的移动行为
onItemMove()
}
//开始动画
start()
this
}
}
private fun abortAnimation() {
mAnimator?.let { if (it.isRunning) it.cancel() }
}
创建动画对象那里,对于还没开始学习Kotlin的同学可能会觉得很陌生,那个with看起来好像是跟java中的switch、if、else这些关键字一样
其实不是,它也是一个方法:接收一个对象,然后返回一个对象,不难看出,我们传进去的是一个ValueAnimator对象,后面紧跟的{ }先忽略,直接看里面的内容:它一开始的赋值,和接下来的调用addUpdateListener和start方法,都是ValueAnimator的,也就是说,在Kotlin中,使用这个with方法之后,后面的lambda可以直接访问with参数里面的属性和方法,而不用指定对象(xxx.),最后的this,就是返回传进去的这个对象。
那个abortAnimation方法,其实等于java的:
private void abortAnimation() {
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
}
好啦,来看看效果:
可以可以。
最后还有一个小节 处理点击事件 因为篇幅原因被省略了...相对来说不影响全文阅读,感兴趣请移步原文。
哈哈哈,可以啦~
发下最终的效果图:
那个切换方向,还有指定最大缩放比例、最大不透明度就当留给同学们的作业啦,在Github上有源码。
好了,本篇文章到此结束,有错误的地方请指出,谢谢大家!
Github地址:
https://github.com/wuyr/LitePager
欢迎Star
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!